mirror of
https://github.com/anchore/syft.git
synced 2025-11-17 16:33:21 +01:00
Add query by MIME type to source.FileResolver (#529)
* add query by MIME type to source.FileResolver Signed-off-by: Alex Goodman <alex.goodman@anchore.com> * pull in stereoscope MIME type feature Signed-off-by: Alex Goodman <alex.goodman@anchore.com> * fix tests Signed-off-by: Alex Goodman <alex.goodman@anchore.com>
This commit is contained in:
parent
ba1cd8d753
commit
9189ed68df
2
go.mod
2
go.mod
@ -11,7 +11,7 @@ require (
|
|||||||
github.com/anchore/go-testutils v0.0.0-20200925183923-d5f45b0d3c04
|
github.com/anchore/go-testutils v0.0.0-20200925183923-d5f45b0d3c04
|
||||||
github.com/anchore/go-version v1.2.2-0.20200701162849-18adb9c92b9b
|
github.com/anchore/go-version v1.2.2-0.20200701162849-18adb9c92b9b
|
||||||
github.com/anchore/packageurl-go v0.0.0-20210922164639-b3fa992ebd29
|
github.com/anchore/packageurl-go v0.0.0-20210922164639-b3fa992ebd29
|
||||||
github.com/anchore/stereoscope v0.0.0-20210817160504-0f4abc2a5a5a
|
github.com/anchore/stereoscope v0.0.0-20211005213828-538011008578
|
||||||
github.com/antihax/optional v1.0.0
|
github.com/antihax/optional v1.0.0
|
||||||
github.com/bmatcuk/doublestar/v2 v2.0.4
|
github.com/bmatcuk/doublestar/v2 v2.0.4
|
||||||
github.com/docker/docker v17.12.0-ce-rc1.0.20200309214505-aa6a9891b09c+incompatible
|
github.com/docker/docker v17.12.0-ce-rc1.0.20200309214505-aa6a9891b09c+incompatible
|
||||||
|
|||||||
9
go.sum
9
go.sum
@ -117,8 +117,8 @@ github.com/anchore/go-version v1.2.2-0.20200701162849-18adb9c92b9b h1:e1bmaoJfZV
|
|||||||
github.com/anchore/go-version v1.2.2-0.20200701162849-18adb9c92b9b/go.mod h1:Bkc+JYWjMCF8OyZ340IMSIi2Ebf3uwByOk6ho4wne1E=
|
github.com/anchore/go-version v1.2.2-0.20200701162849-18adb9c92b9b/go.mod h1:Bkc+JYWjMCF8OyZ340IMSIi2Ebf3uwByOk6ho4wne1E=
|
||||||
github.com/anchore/packageurl-go v0.0.0-20210922164639-b3fa992ebd29 h1:K9LfnxwhqvihqU0+MF325FNy7fsKV9EGaUxdfR4gnWk=
|
github.com/anchore/packageurl-go v0.0.0-20210922164639-b3fa992ebd29 h1:K9LfnxwhqvihqU0+MF325FNy7fsKV9EGaUxdfR4gnWk=
|
||||||
github.com/anchore/packageurl-go v0.0.0-20210922164639-b3fa992ebd29/go.mod h1:Oc1UkGaJwY6ND6vtAqPSlYrptKRJngHwkwB6W7l1uP0=
|
github.com/anchore/packageurl-go v0.0.0-20210922164639-b3fa992ebd29/go.mod h1:Oc1UkGaJwY6ND6vtAqPSlYrptKRJngHwkwB6W7l1uP0=
|
||||||
github.com/anchore/stereoscope v0.0.0-20210817160504-0f4abc2a5a5a h1:RQb+Gft1MKxjDfJCnHP/f1mwfy0Jz50Kp9QGgSWKQiY=
|
github.com/anchore/stereoscope v0.0.0-20211005213828-538011008578 h1:gSpftl0RWfdTwlPmsOLgEawHIw16xwwG1mFD/wrVDRE=
|
||||||
github.com/anchore/stereoscope v0.0.0-20210817160504-0f4abc2a5a5a/go.mod h1:165DfE5jApgEkHTWwu7Bijeml9fofudrgcpuWaD9+tk=
|
github.com/anchore/stereoscope v0.0.0-20211005213828-538011008578/go.mod h1:kL7jfbXblrDcBhu5ja/s+VTYL3Mzof4eQNMJiSqcwXQ=
|
||||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
|
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
|
||||||
github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg=
|
github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg=
|
||||||
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
||||||
@ -242,6 +242,8 @@ github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHqu
|
|||||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.3.0 h1:4YOHITFLyYwF+iqG0ybSLGArRItynpfwdlWRmJnd75E=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.3.0/go.mod h1:fA8fi6KUiG7MgQQ+mEWotXoEOvmxRtOJlERCzSmRvr8=
|
||||||
github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||||
github.com/go-critic/go-critic v0.4.1/go.mod h1:7/14rZGnZbY6E38VEGk2kVhoq6itzc1E68facVDK23g=
|
github.com/go-critic/go-critic v0.4.1/go.mod h1:7/14rZGnZbY6E38VEGk2kVhoq6itzc1E68facVDK23g=
|
||||||
@ -872,7 +874,7 @@ golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/
|
|||||||
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
golang.org/x/net v0.0.0-20200602114024-627f9648deb9/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d h1:LO7XpTYMwTqxjLcGWPijK3vRXg1aWdlNOVOHRq45d7c=
|
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d h1:LO7XpTYMwTqxjLcGWPijK3vRXg1aWdlNOVOHRq45d7c=
|
||||||
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
@ -939,7 +941,6 @@ golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||||||
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da h1:b3NXsE2LusjYGGjL5bxEVZZORm/YEFFrWFjR8eFrw/c=
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da h1:b3NXsE2LusjYGGjL5bxEVZZORm/YEFFrWFjR8eFrw/c=
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
|||||||
@ -22,6 +22,7 @@ type JSONFileMetadataEntry struct {
|
|||||||
UserID int `json:"userID"`
|
UserID int `json:"userID"`
|
||||||
GroupID int `json:"groupID"`
|
GroupID int `json:"groupID"`
|
||||||
Digests []file.Digest `json:"digests,omitempty"`
|
Digests []file.Digest `json:"digests,omitempty"`
|
||||||
|
MIMEType string `json:"mimeType"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewJSONFileMetadata(data map[source.Location]source.FileMetadata, digests map[source.Location][]file.Digest) ([]JSONFileMetadata, error) {
|
func NewJSONFileMetadata(data map[source.Location]source.FileMetadata, digests map[source.Location][]file.Digest) ([]JSONFileMetadata, error) {
|
||||||
@ -46,6 +47,7 @@ func NewJSONFileMetadata(data map[source.Location]source.FileMetadata, digests m
|
|||||||
UserID: metadata.UserID,
|
UserID: metadata.UserID,
|
||||||
GroupID: metadata.GroupID,
|
GroupID: metadata.GroupID,
|
||||||
Digests: digestResults,
|
Digests: digestResults,
|
||||||
|
MIMEType: metadata.MIMEType,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,7 +16,8 @@
|
|||||||
"mode": 775,
|
"mode": 775,
|
||||||
"type": "directory",
|
"type": "directory",
|
||||||
"userID": 0,
|
"userID": 0,
|
||||||
"groupID": 0
|
"groupID": 0,
|
||||||
|
"mimeType": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -33,7 +34,8 @@
|
|||||||
"algorithm": "sha256",
|
"algorithm": "sha256",
|
||||||
"value": "366a3f5653e34673b875891b021647440d0127c2ef041e3b1a22da2a7d4f3703"
|
"value": "366a3f5653e34673b875891b021647440d0127c2ef041e3b1a22da2a7d4f3703"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"mimeType": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -45,7 +47,8 @@
|
|||||||
"type": "symbolicLink",
|
"type": "symbolicLink",
|
||||||
"linkDestination": "/c",
|
"linkDestination": "/c",
|
||||||
"userID": 0,
|
"userID": 0,
|
||||||
"groupID": 0
|
"groupID": 0,
|
||||||
|
"mimeType": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -62,7 +65,8 @@
|
|||||||
"algorithm": "sha256",
|
"algorithm": "sha256",
|
||||||
"value": "1b3722da2a7d90d033b87581a2a3f12021647445653e34666ef041e3b4f3707c"
|
"value": "1b3722da2a7d90d033b87581a2a3f12021647445653e34666ef041e3b4f3707c"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"mimeType": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@ -49,11 +49,12 @@ func TestFileMetadataCataloger(t *testing.T) {
|
|||||||
path: "/file-1.txt",
|
path: "/file-1.txt",
|
||||||
exists: true,
|
exists: true,
|
||||||
expected: source.FileMetadata{
|
expected: source.FileMetadata{
|
||||||
Mode: 0644,
|
Mode: 0644,
|
||||||
Type: "RegularFile",
|
Type: "RegularFile",
|
||||||
UserID: 1,
|
UserID: 1,
|
||||||
GroupID: 2,
|
GroupID: 2,
|
||||||
Size: 7,
|
Size: 7,
|
||||||
|
MIMEType: "text/plain",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -65,6 +66,7 @@ func TestFileMetadataCataloger(t *testing.T) {
|
|||||||
LinkDestination: "file-1.txt",
|
LinkDestination: "file-1.txt",
|
||||||
UserID: 1,
|
UserID: 1,
|
||||||
GroupID: 2,
|
GroupID: 2,
|
||||||
|
MIMEType: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -76,46 +78,51 @@ func TestFileMetadataCataloger(t *testing.T) {
|
|||||||
LinkDestination: "file-1.txt",
|
LinkDestination: "file-1.txt",
|
||||||
UserID: 0,
|
UserID: 0,
|
||||||
GroupID: 0,
|
GroupID: 0,
|
||||||
|
MIMEType: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/char-device-1",
|
path: "/char-device-1",
|
||||||
exists: true,
|
exists: true,
|
||||||
expected: source.FileMetadata{
|
expected: source.FileMetadata{
|
||||||
Mode: 0644 | os.ModeDevice | os.ModeCharDevice,
|
Mode: 0644 | os.ModeDevice | os.ModeCharDevice,
|
||||||
Type: "CharacterDevice",
|
Type: "CharacterDevice",
|
||||||
UserID: 0,
|
UserID: 0,
|
||||||
GroupID: 0,
|
GroupID: 0,
|
||||||
|
MIMEType: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/block-device-1",
|
path: "/block-device-1",
|
||||||
exists: true,
|
exists: true,
|
||||||
expected: source.FileMetadata{
|
expected: source.FileMetadata{
|
||||||
Mode: 0644 | os.ModeDevice,
|
Mode: 0644 | os.ModeDevice,
|
||||||
Type: "BlockDevice",
|
Type: "BlockDevice",
|
||||||
UserID: 0,
|
UserID: 0,
|
||||||
GroupID: 0,
|
GroupID: 0,
|
||||||
|
MIMEType: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/fifo-1",
|
path: "/fifo-1",
|
||||||
exists: true,
|
exists: true,
|
||||||
expected: source.FileMetadata{
|
expected: source.FileMetadata{
|
||||||
Mode: 0644 | os.ModeNamedPipe,
|
Mode: 0644 | os.ModeNamedPipe,
|
||||||
Type: "FIFONode",
|
Type: "FIFONode",
|
||||||
UserID: 0,
|
UserID: 0,
|
||||||
GroupID: 0,
|
GroupID: 0,
|
||||||
|
MIMEType: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/bin",
|
path: "/bin",
|
||||||
exists: true,
|
exists: true,
|
||||||
expected: source.FileMetadata{
|
expected: source.FileMetadata{
|
||||||
Mode: 0755 | os.ModeDir,
|
Mode: 0755 | os.ModeDir,
|
||||||
Type: "Directory",
|
Type: "Directory",
|
||||||
UserID: 0,
|
UserID: 0,
|
||||||
GroupID: 0,
|
GroupID: 0,
|
||||||
|
MIMEType: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -45,11 +45,16 @@ func (r *rpmdbTestFileResolverMock) FilesByPath(paths ...string) ([]source.Locat
|
|||||||
func (r *rpmdbTestFileResolverMock) FilesByGlob(...string) ([]source.Location, error) {
|
func (r *rpmdbTestFileResolverMock) FilesByGlob(...string) ([]source.Location, error) {
|
||||||
return nil, fmt.Errorf("not implemented")
|
return nil, fmt.Errorf("not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *rpmdbTestFileResolverMock) RelativeFileByPath(source.Location, string) *source.Location {
|
func (r *rpmdbTestFileResolverMock) RelativeFileByPath(source.Location, string) *source.Location {
|
||||||
panic(fmt.Errorf("not implemented"))
|
panic(fmt.Errorf("not implemented"))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r rpmdbTestFileResolverMock) FilesByMIMEType(types ...string) ([]source.Location, error) {
|
||||||
|
panic("implement me")
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseRpmDB(t *testing.T) {
|
func TestParseRpmDB(t *testing.T) {
|
||||||
dbLocation := source.NewLocation("test-path")
|
dbLocation := source.NewLocation("test-path")
|
||||||
|
|
||||||
|
|||||||
@ -188,6 +188,24 @@ func (r *allLayersResolver) FileContentsByLocation(location Location) (io.ReadCl
|
|||||||
return r.img.FileContentsByRef(location.ref)
|
return r.img.FileContentsByRef(location.ref)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *allLayersResolver) FilesByMIMEType(types ...string) ([]Location, error) {
|
||||||
|
var locations []Location
|
||||||
|
for _, layerIdx := range r.layers {
|
||||||
|
layer := r.img.Layers[layerIdx]
|
||||||
|
|
||||||
|
refs, err := layer.FilesByMIMEType(types...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ref := range refs {
|
||||||
|
locations = append(locations, NewLocationFromReference(ref))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return locations, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *allLayersResolver) AllLocations() <-chan Location {
|
func (r *allLayersResolver) AllLocations() <-chan Location {
|
||||||
results := make(chan Location)
|
results := make(chan Location)
|
||||||
go func() {
|
go func() {
|
||||||
|
|||||||
@ -3,6 +3,8 @@ package source
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
"github.com/anchore/stereoscope/pkg/imagetest"
|
"github.com/anchore/stereoscope/pkg/imagetest"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -239,3 +241,34 @@ func TestAllLayersResolver_FilesByGlob(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Test_imageAllLayersResolver_FilesByMIMEType(t *testing.T) {
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
fixtureName string
|
||||||
|
mimeType string
|
||||||
|
expectedPaths []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
fixtureName: "image-duplicate-path",
|
||||||
|
mimeType: "text/plain",
|
||||||
|
expectedPaths: []string{"/somefile-1.txt", "/somefile-1.txt"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.fixtureName, func(t *testing.T) {
|
||||||
|
img := imagetest.GetFixtureImage(t, "docker-archive", test.fixtureName)
|
||||||
|
|
||||||
|
resolver, err := newAllLayersResolver(img)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
locations, err := resolver.FilesByMIMEType(test.mimeType)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Len(t, test.expectedPaths, len(locations))
|
||||||
|
for idx, l := range locations {
|
||||||
|
assert.Equal(t, test.expectedPaths[idx], l.RealPath, "does not have path %q", l.RealPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import (
|
|||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
|
||||||
|
|
||||||
"github.com/anchore/stereoscope/pkg/file"
|
"github.com/anchore/stereoscope/pkg/file"
|
||||||
"github.com/anchore/stereoscope/pkg/filetree"
|
"github.com/anchore/stereoscope/pkg/filetree"
|
||||||
@ -35,10 +34,11 @@ type directoryResolver struct {
|
|||||||
path string
|
path string
|
||||||
cwd string
|
cwd string
|
||||||
fileTree *filetree.FileTree
|
fileTree *filetree.FileTree
|
||||||
infos map[file.ID]os.FileInfo
|
metadata map[file.ID]FileMetadata
|
||||||
// TODO: wire up to report these paths in the json report
|
// TODO: wire up to report these paths in the json report
|
||||||
pathFilterFns []pathFilterFn
|
pathFilterFns []pathFilterFn
|
||||||
errPaths map[string]error
|
refsByMIMEType map[string][]file.Reference
|
||||||
|
errPaths map[string]error
|
||||||
}
|
}
|
||||||
|
|
||||||
func newDirectoryResolver(root string, pathFilters ...pathFilterFn) (*directoryResolver, error) {
|
func newDirectoryResolver(root string, pathFilters ...pathFilterFn) (*directoryResolver, error) {
|
||||||
@ -52,12 +52,13 @@ func newDirectoryResolver(root string, pathFilters ...pathFilterFn) (*directoryR
|
|||||||
}
|
}
|
||||||
|
|
||||||
resolver := directoryResolver{
|
resolver := directoryResolver{
|
||||||
path: root,
|
path: root,
|
||||||
cwd: cwd,
|
cwd: cwd,
|
||||||
fileTree: filetree.NewFileTree(),
|
fileTree: filetree.NewFileTree(),
|
||||||
infos: make(map[file.ID]os.FileInfo),
|
metadata: make(map[file.ID]FileMetadata),
|
||||||
pathFilterFns: pathFilters,
|
pathFilterFns: pathFilters,
|
||||||
errPaths: make(map[string]error),
|
refsByMIMEType: make(map[string][]file.Reference),
|
||||||
|
errPaths: make(map[string]error),
|
||||||
}
|
}
|
||||||
|
|
||||||
return &resolver, indexAllRoots(root, resolver.indexTree)
|
return &resolver, indexAllRoots(root, resolver.indexTree)
|
||||||
@ -158,7 +159,11 @@ func (r directoryResolver) addPathToIndex(p string, info os.FileInfo) (string, e
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
r.infos[ref.ID()] = info
|
metadata := fileMetadataFromPath(p, info)
|
||||||
|
if ref != nil {
|
||||||
|
r.refsByMIMEType[metadata.MIMEType] = append(r.refsByMIMEType[metadata.MIMEType], *ref)
|
||||||
|
}
|
||||||
|
r.metadata[ref.ID()] = metadata
|
||||||
return newRoot, nil
|
return newRoot, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -207,6 +212,7 @@ func (r directoryResolver) FilesByPath(userPaths ...string) ([]Location, error)
|
|||||||
log.Warnf("unable to get file by path=%q : %+v", userPath, err)
|
log.Warnf("unable to get file by path=%q : %+v", userPath, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// TODO: why not use stored metadata?
|
||||||
fileMeta, err := os.Stat(userStrPath)
|
fileMeta, err := os.Stat(userStrPath)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
continue
|
continue
|
||||||
@ -280,25 +286,24 @@ func (r *directoryResolver) AllLocations() <-chan Location {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *directoryResolver) FileMetadataByLocation(location Location) (FileMetadata, error) {
|
func (r *directoryResolver) FileMetadataByLocation(location Location) (FileMetadata, error) {
|
||||||
info, exists := r.infos[location.ref.ID()]
|
metadata, exists := r.metadata[location.ref.ID()]
|
||||||
if !exists {
|
if !exists {
|
||||||
return FileMetadata{}, fmt.Errorf("location: %+v : %w", location, os.ErrNotExist)
|
return FileMetadata{}, fmt.Errorf("location: %+v : %w", location, os.ErrNotExist)
|
||||||
}
|
}
|
||||||
|
|
||||||
uid := -1
|
return metadata, nil
|
||||||
gid := -1
|
}
|
||||||
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
|
|
||||||
uid = int(stat.Uid)
|
|
||||||
gid = int(stat.Gid)
|
|
||||||
}
|
|
||||||
|
|
||||||
return FileMetadata{
|
func (r *directoryResolver) FilesByMIMEType(types ...string) ([]Location, error) {
|
||||||
Mode: info.Mode(),
|
var locations []Location
|
||||||
Type: newFileTypeFromMode(info.Mode()),
|
for _, ty := range types {
|
||||||
// unsupported across platforms
|
if refs, ok := r.refsByMIMEType[ty]; ok {
|
||||||
UserID: uid,
|
for _, ref := range refs {
|
||||||
GroupID: gid,
|
locations = append(locations, NewLocationFromDirectory(r.responsePath(string(ref.RealPath)), ref))
|
||||||
}, nil
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return locations, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func isUnixSystemRuntimePath(path string) bool {
|
func isUnixSystemRuntimePath(path string) bool {
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/scylladb/go-set/strset"
|
||||||
|
|
||||||
"github.com/anchore/stereoscope/pkg/file"
|
"github.com/anchore/stereoscope/pkg/file"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/wagoodman/go-progress"
|
"github.com/wagoodman/go-progress"
|
||||||
@ -303,7 +305,7 @@ func Test_directoryResolver_index(t *testing.T) {
|
|||||||
if assert.NoError(t, err) {
|
if assert.NoError(t, err) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
assert.Equal(t, info, r.infos[ref.ID()])
|
assert.Equal(t, info, r.metadata[ref.ID()])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -429,3 +431,31 @@ func Test_indexAllRoots(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Test_directoryResolver_FilesByMIMEType(t *testing.T) {
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
fixturePath string
|
||||||
|
mimeType string
|
||||||
|
expectedPaths *strset.Set
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
fixturePath: "./test-fixtures/image-simple",
|
||||||
|
mimeType: "text/plain",
|
||||||
|
expectedPaths: strset.New("test-fixtures/image-simple/file-1.txt", "test-fixtures/image-simple/file-2.txt", "test-fixtures/image-simple/target/really/nested/file-3.txt", "test-fixtures/image-simple/Dockerfile"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.fixturePath, func(t *testing.T) {
|
||||||
|
|
||||||
|
resolver, err := newDirectoryResolver(test.fixturePath)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
locations, err := resolver.FilesByMIMEType(test.mimeType)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, test.expectedPaths.Size(), len(locations))
|
||||||
|
for _, l := range locations {
|
||||||
|
assert.True(t, test.expectedPaths.Has(l.RealPath), "does not have path %q", l.RealPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -2,6 +2,11 @@ package source
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/anchore/syft/internal/log"
|
||||||
|
|
||||||
|
"github.com/anchore/stereoscope/pkg/file"
|
||||||
|
|
||||||
"github.com/anchore/stereoscope/pkg/image"
|
"github.com/anchore/stereoscope/pkg/image"
|
||||||
)
|
)
|
||||||
@ -13,6 +18,7 @@ type FileMetadata struct {
|
|||||||
GroupID int
|
GroupID int
|
||||||
LinkDestination string
|
LinkDestination string
|
||||||
Size int64
|
Size int64
|
||||||
|
MIMEType string
|
||||||
}
|
}
|
||||||
|
|
||||||
func fileMetadataByLocation(img *image.Image, location Location) (FileMetadata, error) {
|
func fileMetadataByLocation(img *image.Image, location Location) (FileMetadata, error) {
|
||||||
@ -28,5 +34,36 @@ func fileMetadataByLocation(img *image.Image, location Location) (FileMetadata,
|
|||||||
GroupID: entry.Metadata.GroupID,
|
GroupID: entry.Metadata.GroupID,
|
||||||
LinkDestination: entry.Metadata.Linkname,
|
LinkDestination: entry.Metadata.Linkname,
|
||||||
Size: entry.Metadata.Size,
|
Size: entry.Metadata.Size,
|
||||||
|
MIMEType: entry.Metadata.MIMEType,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func fileMetadataFromPath(path string, info os.FileInfo) FileMetadata {
|
||||||
|
uid := -1
|
||||||
|
gid := -1
|
||||||
|
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
|
||||||
|
uid = int(stat.Uid)
|
||||||
|
gid = int(stat.Gid)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
// TODO: it may be that the file is inaccessible, however, this is not an error or a warning. In the future we need to track these as known-unknowns
|
||||||
|
f = nil
|
||||||
|
} else {
|
||||||
|
defer func() {
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
log.Warnf("unable to close file while obtaining metadata: %s", path)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
return FileMetadata{
|
||||||
|
Mode: info.Mode(),
|
||||||
|
Type: newFileTypeFromMode(info.Mode()),
|
||||||
|
// unsupported across platforms
|
||||||
|
UserID: uid,
|
||||||
|
GroupID: gid,
|
||||||
|
MIMEType: file.MIMEType(f),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -29,6 +29,8 @@ type FilePathResolver interface {
|
|||||||
FilesByPath(paths ...string) ([]Location, error)
|
FilesByPath(paths ...string) ([]Location, error)
|
||||||
// FilesByGlob fetches a set of file references which the given glob matches
|
// FilesByGlob fetches a set of file references which the given glob matches
|
||||||
FilesByGlob(patterns ...string) ([]Location, error)
|
FilesByGlob(patterns ...string) ([]Location, error)
|
||||||
|
// FilesByGlob fetches a set of file references which the contents have been classified as one of the given MIME Types
|
||||||
|
FilesByMIMEType(types ...string) ([]Location, error)
|
||||||
// RelativeFileByPath fetches a single file at the given path relative to the layer squash of the given reference.
|
// RelativeFileByPath fetches a single file at the given path relative to the layer squash of the given reference.
|
||||||
// This is helpful when attempting to find a file that is in the same layer or lower as another file.
|
// This is helpful when attempting to find a file that is in the same layer or lower as another file.
|
||||||
RelativeFileByPath(_ Location, path string) *Location
|
RelativeFileByPath(_ Location, path string) *Location
|
||||||
|
|||||||
@ -151,6 +151,20 @@ func (r *imageSquashResolver) AllLocations() <-chan Location {
|
|||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *imageSquashResolver) FilesByMIMEType(types ...string) ([]Location, error) {
|
||||||
|
refs, err := r.img.FilesByMIMETypeFromSquash(types...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var locations []Location
|
||||||
|
for _, ref := range refs {
|
||||||
|
locations = append(locations, NewLocationFromReference(ref))
|
||||||
|
}
|
||||||
|
|
||||||
|
return locations, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *imageSquashResolver) FileMetadataByLocation(location Location) (FileMetadata, error) {
|
func (r *imageSquashResolver) FileMetadataByLocation(location Location) (FileMetadata, error) {
|
||||||
return fileMetadataByLocation(r.img, location)
|
return fileMetadataByLocation(r.img, location)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,9 @@ package source
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/scylladb/go-set/strset"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
"github.com/anchore/stereoscope/pkg/imagetest"
|
"github.com/anchore/stereoscope/pkg/imagetest"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -225,3 +228,34 @@ func TestImageSquashResolver_FilesByGlob(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Test_imageSquashResolver_FilesByMIMEType(t *testing.T) {
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
fixtureName string
|
||||||
|
mimeType string
|
||||||
|
expectedPaths *strset.Set
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
fixtureName: "image-simple",
|
||||||
|
mimeType: "text/plain",
|
||||||
|
expectedPaths: strset.New("/somefile-1.txt", "/somefile-2.txt", "/really/nested/file-3.txt"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.fixtureName, func(t *testing.T) {
|
||||||
|
img := imagetest.GetFixtureImage(t, "docker-archive", test.fixtureName)
|
||||||
|
|
||||||
|
resolver, err := newImageSquashResolver(img)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
locations, err := resolver.FilesByMIMEType(test.mimeType)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, test.expectedPaths.Size(), len(locations))
|
||||||
|
for _, l := range locations {
|
||||||
|
assert.True(t, test.expectedPaths.Has(l.RealPath), "does not have path %q", l.RealPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -14,8 +14,9 @@ var _ FileResolver = (*MockResolver)(nil)
|
|||||||
// It provides an implementation that can resolve local filesystem paths using only a provided discrete list of file
|
// It provides an implementation that can resolve local filesystem paths using only a provided discrete list of file
|
||||||
// paths, which are typically paths to test fixtures.
|
// paths, which are typically paths to test fixtures.
|
||||||
type MockResolver struct {
|
type MockResolver struct {
|
||||||
Locations []Location
|
locations []Location
|
||||||
Metadata map[Location]FileMetadata
|
metadata map[Location]FileMetadata
|
||||||
|
mimeTypeIndex map[string][]Location
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMockResolverForPaths creates a new MockResolver, where the only resolvable
|
// NewMockResolverForPaths creates a new MockResolver, where the only resolvable
|
||||||
@ -26,21 +27,30 @@ func NewMockResolverForPaths(paths ...string) *MockResolver {
|
|||||||
locations = append(locations, NewLocation(p))
|
locations = append(locations, NewLocation(p))
|
||||||
}
|
}
|
||||||
|
|
||||||
return &MockResolver{Locations: locations}
|
return &MockResolver{
|
||||||
|
locations: locations,
|
||||||
|
metadata: make(map[Location]FileMetadata),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMockResolverForPathsWithMetadata(metadata map[Location]FileMetadata) *MockResolver {
|
func NewMockResolverForPathsWithMetadata(metadata map[Location]FileMetadata) *MockResolver {
|
||||||
var locations []Location
|
var locations []Location
|
||||||
for p := range metadata {
|
var mimeTypeIndex = make(map[string][]Location)
|
||||||
locations = append(locations, p)
|
for l, m := range metadata {
|
||||||
|
locations = append(locations, l)
|
||||||
|
mimeTypeIndex[m.MIMEType] = append(mimeTypeIndex[m.MIMEType], l)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &MockResolver{Locations: locations, Metadata: metadata}
|
return &MockResolver{
|
||||||
|
locations: locations,
|
||||||
|
metadata: metadata,
|
||||||
|
mimeTypeIndex: mimeTypeIndex,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasPath indicates if the given path exists in the underlying source.
|
// HasPath indicates if the given path exists in the underlying source.
|
||||||
func (r MockResolver) HasPath(path string) bool {
|
func (r MockResolver) HasPath(path string) bool {
|
||||||
for _, l := range r.Locations {
|
for _, l := range r.locations {
|
||||||
if l.RealPath == path {
|
if l.RealPath == path {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@ -50,13 +60,13 @@ func (r MockResolver) HasPath(path string) bool {
|
|||||||
|
|
||||||
// String returns the string representation of the MockResolver.
|
// String returns the string representation of the MockResolver.
|
||||||
func (r MockResolver) String() string {
|
func (r MockResolver) String() string {
|
||||||
return fmt.Sprintf("mock:(%s,...)", r.Locations[0].RealPath)
|
return fmt.Sprintf("mock:(%s,...)", r.locations[0].RealPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileContentsByLocation fetches file contents for a single location. If the
|
// FileContentsByLocation fetches file contents for a single location. If the
|
||||||
// path does not exist, an error is returned.
|
// path does not exist, an error is returned.
|
||||||
func (r MockResolver) FileContentsByLocation(location Location) (io.ReadCloser, error) {
|
func (r MockResolver) FileContentsByLocation(location Location) (io.ReadCloser, error) {
|
||||||
for _, l := range r.Locations {
|
for _, l := range r.locations {
|
||||||
if l == location {
|
if l == location {
|
||||||
return os.Open(location.RealPath)
|
return os.Open(location.RealPath)
|
||||||
}
|
}
|
||||||
@ -69,7 +79,7 @@ func (r MockResolver) FileContentsByLocation(location Location) (io.ReadCloser,
|
|||||||
func (r MockResolver) FilesByPath(paths ...string) ([]Location, error) {
|
func (r MockResolver) FilesByPath(paths ...string) ([]Location, error) {
|
||||||
var results []Location
|
var results []Location
|
||||||
for _, p := range paths {
|
for _, p := range paths {
|
||||||
for _, location := range r.Locations {
|
for _, location := range r.locations {
|
||||||
if p == location.RealPath {
|
if p == location.RealPath {
|
||||||
results = append(results, NewLocation(p))
|
results = append(results, NewLocation(p))
|
||||||
}
|
}
|
||||||
@ -83,7 +93,7 @@ func (r MockResolver) FilesByPath(paths ...string) ([]Location, error) {
|
|||||||
func (r MockResolver) FilesByGlob(patterns ...string) ([]Location, error) {
|
func (r MockResolver) FilesByGlob(patterns ...string) ([]Location, error) {
|
||||||
var results []Location
|
var results []Location
|
||||||
for _, pattern := range patterns {
|
for _, pattern := range patterns {
|
||||||
for _, location := range r.Locations {
|
for _, location := range r.locations {
|
||||||
matches, err := doublestar.Match(pattern, location.RealPath)
|
matches, err := doublestar.Match(pattern, location.RealPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -115,7 +125,7 @@ func (r MockResolver) AllLocations() <-chan Location {
|
|||||||
results := make(chan Location)
|
results := make(chan Location)
|
||||||
go func() {
|
go func() {
|
||||||
defer close(results)
|
defer close(results)
|
||||||
for _, l := range r.Locations {
|
for _, l := range r.locations {
|
||||||
results <- l
|
results <- l
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@ -142,3 +152,11 @@ func (r MockResolver) FileMetadataByLocation(l Location) (FileMetadata, error) {
|
|||||||
Size: info.Size(),
|
Size: info.Size(),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r MockResolver) FilesByMIMEType(types ...string) ([]Location, error) {
|
||||||
|
var locations []Location
|
||||||
|
for _, ty := range types {
|
||||||
|
locations = append(r.mimeTypeIndex[ty], locations...)
|
||||||
|
}
|
||||||
|
return locations, nil
|
||||||
|
}
|
||||||
|
|||||||
@ -0,0 +1,4 @@
|
|||||||
|
# Note: changes to this file will result in updating several test values. Consider making a new image fixture instead of editing this one.
|
||||||
|
FROM scratch
|
||||||
|
ADD file-1.txt /somefile-1.txt
|
||||||
|
ADD file-2.txt /somefile-1.txt
|
||||||
@ -0,0 +1 @@
|
|||||||
|
this file has contents
|
||||||
@ -0,0 +1 @@
|
|||||||
|
file-2 contents!
|
||||||
Loading…
x
Reference in New Issue
Block a user