Skip to content
This repository was archived by the owner on Jul 18, 2025. It is now read-only.

Commit 04c3a28

Browse files
authored
Merge pull request #561 from silvin-lubecki/fix-crlf-singlefile-app
Fix: A single-file app can have CRLF and random YAML documents order
2 parents 98b71d2 + cfc5966 commit 04c3a28

File tree

12 files changed

+249
-62
lines changed

12 files changed

+249
-62
lines changed

e2e/commands_test.go

Lines changed: 67 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
package e2e
22

33
import (
4+
"bytes"
45
"encoding/json"
56
"fmt"
67
"io/ioutil"
8+
"path"
79
"path/filepath"
810
"regexp"
911
"strings"
@@ -207,37 +209,80 @@ func TestDetectApp(t *testing.T) {
207209
})
208210
}
209211

212+
func patchEndOfLine(t *testing.T, eol []byte, name, fromDir, toDir string) {
213+
buf := golden.Get(t, filepath.Join(fromDir, name))
214+
bytes.ReplaceAll(buf, []byte{'\n'}, eol)
215+
assert.NilError(t, ioutil.WriteFile(filepath.Join(toDir, name), buf, 0644))
216+
}
217+
210218
func TestSplitMerge(t *testing.T) {
211-
cmd, cleanup := dockerCli.createTestCmd()
212-
defer cleanup()
219+
testCases := []struct {
220+
name string
221+
lineEnding []byte
222+
}{
223+
{
224+
name: "without-crlf",
225+
lineEnding: []byte{'\n'},
226+
},
227+
{
228+
name: "with-crlf",
229+
lineEnding: []byte{'\r', '\n'},
230+
},
231+
}
232+
for _, test := range testCases {
233+
t.Run(test.name, func(t *testing.T) {
234+
cmd, cleanup := dockerCli.createTestCmd()
235+
defer cleanup()
213236

214-
tmpDir := fs.NewDir(t, "split_merge")
215-
defer tmpDir.Remove()
237+
tmpDir := fs.NewDir(t, "split_merge", fs.WithDir("my.dockerapp"))
238+
defer tmpDir.Remove()
216239

217-
cmd.Command = dockerCli.Command("app", "merge", "testdata/render/envvariables/my.dockerapp", "--output", tmpDir.Join("remerged.dockerapp"))
218-
icmd.RunCmd(cmd).Assert(t, icmd.Success)
240+
inputDir := filepath.Join("render", "envvariables", "my.dockerapp")
241+
testDataDir := tmpDir.Join("my.dockerapp")
219242

220-
cmd.Dir = tmpDir.Path()
243+
// replace the line ending from the versioned test data files and put them to a temp dir
244+
for _, name := range internal.FileNames {
245+
patchEndOfLine(t, test.lineEnding, name, inputDir, testDataDir)
246+
}
221247

222-
// test that inspect works on single-file
223-
cmd.Command = dockerCli.Command("app", "inspect", "remerged")
224-
result := icmd.RunCmd(cmd).Assert(t, icmd.Success)
225-
golden.Assert(t, result.Combined(), "envvariables-inspect.golden")
248+
// Merge a multi-files app to a single-file app
249+
cmd.Command = dockerCli.Command("app", "merge", testDataDir, "--output", tmpDir.Join("remerged.dockerapp"))
250+
icmd.RunCmd(cmd).Assert(t, icmd.Success)
226251

227-
// split it
228-
cmd.Command = dockerCli.Command("app", "split", "remerged", "--output", "split.dockerapp")
229-
icmd.RunCmd(cmd).Assert(t, icmd.Success)
252+
cmd.Dir = tmpDir.Path()
230253

231-
cmd.Command = dockerCli.Command("app", "inspect", "remerged")
232-
result = icmd.RunCmd(cmd).Assert(t, icmd.Success)
233-
golden.Assert(t, result.Combined(), "envvariables-inspect.golden")
254+
// test that inspect works on single-file
255+
cmd.Command = dockerCli.Command("app", "inspect", "remerged")
256+
result := icmd.RunCmd(cmd).Assert(t, icmd.Success)
257+
golden.Assert(t, result.Combined(), "envvariables-inspect.golden")
234258

235-
// test inplace
236-
cmd.Command = dockerCli.Command("app", "merge", "split")
237-
icmd.RunCmd(cmd).Assert(t, icmd.Success)
259+
// split it
260+
cmd.Command = dockerCli.Command("app", "split", "remerged", "--output", "split.dockerapp")
261+
icmd.RunCmd(cmd).Assert(t, icmd.Success)
238262

239-
cmd.Command = dockerCli.Command("app", "split", "split")
240-
icmd.RunCmd(cmd).Assert(t, icmd.Success)
263+
// test that inspect still works
264+
cmd.Command = dockerCli.Command("app", "inspect", "remerged")
265+
result = icmd.RunCmd(cmd).Assert(t, icmd.Success)
266+
golden.Assert(t, result.Combined(), "envvariables-inspect.golden")
267+
268+
// the split app must be the same as the original one
269+
manifest := fs.Expected(
270+
t,
271+
fs.WithMode(0755),
272+
fs.WithFile(internal.MetadataFileName, string(golden.Get(t, path.Join(testDataDir, internal.MetadataFileName))), fs.WithMode(0644)),
273+
fs.WithFile(internal.ComposeFileName, string(golden.Get(t, path.Join(testDataDir, internal.ComposeFileName))), fs.WithMode(0644)),
274+
fs.WithFile(internal.ParametersFileName, string(golden.Get(t, path.Join(testDataDir, internal.ParametersFileName))), fs.WithMode(0644)),
275+
)
276+
assert.Assert(t, fs.Equal(tmpDir.Join("split.dockerapp"), manifest))
277+
278+
// test inplace
279+
cmd.Command = dockerCli.Command("app", "merge", "split")
280+
icmd.RunCmd(cmd).Assert(t, icmd.Success)
281+
282+
cmd.Command = dockerCli.Command("app", "split", "split")
283+
icmd.RunCmd(cmd).Assert(t, icmd.Success)
284+
})
285+
}
241286
}
242287

243288
func TestBundle(t *testing.T) {
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
myapp.command3: bam
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
version: "3.4"
2+
services:
3+
test:
4+
command:
5+
- bash
6+
- -c
7+
- cat bar bam
8+
image: alpine:latest
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
version: "3.4"
2+
services:
3+
test:
4+
image: alpine:latest
5+
command: bash -c "${myapp.command1} ${myapp.command2} ${myapp.command3}"
6+
---
7+
myapp:
8+
command1: cat
9+
command2: foo
10+
command3: bar
11+
---
12+
version: 0.1.0
13+
name: myapp
14+
maintainers:
15+
- name: dev
16+
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
myapp:
2+
command2: bar
3+
command3: baz

internal/packager/split.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,9 @@ func Merge(app *types.App, target io.Writer) error {
4545
}
4646
for _, data := range [][]byte{
4747
app.MetadataRaw(),
48-
[]byte(types.SingleFileSeparator),
48+
types.YamlSingleFileSeparator(app.HasCRLF()),
4949
app.Composes()[0],
50-
[]byte(types.SingleFileSeparator),
50+
types.YamlSingleFileSeparator(app.HasCRLF()),
5151
app.ParametersRaw()[0],
5252
} {
5353
if _, err := target.Write(data); err != nil {

loader/loader.go

Lines changed: 71 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,100 @@
11
package loader
22

33
import (
4+
"bytes"
45
"io"
56
"io/ioutil"
67
"os"
78
"path/filepath"
8-
"strings"
99

1010
"github.com/docker/app/internal"
11+
"github.com/docker/app/specification"
1112
"github.com/docker/app/types"
13+
"github.com/docker/cli/cli/compose/loader"
14+
"github.com/docker/cli/cli/compose/schema"
1215
"github.com/docker/docker/pkg/archive"
1316
"github.com/pkg/errors"
1417
)
1518

19+
var (
20+
crlf = []byte{'\r', '\n'}
21+
lf = []byte{'\n'}
22+
delimiters = [][]byte{
23+
[]byte("\r\n---\r\n"),
24+
[]byte("\n---\r\n"),
25+
[]byte("\r\n---\n"),
26+
[]byte("\n---\n"),
27+
}
28+
)
29+
30+
// useCRLF detects which line break should be used
31+
func useCRLF(data []byte) bool {
32+
nbCrlf := bytes.Count(data, crlf)
33+
nbLf := bytes.Count(data, lf)
34+
switch {
35+
case nbCrlf == nbLf:
36+
// document contains only CRLF
37+
return true
38+
case nbCrlf == 0:
39+
// document does not contain any CRLF
40+
return false
41+
default:
42+
// document contains mixed line breaks, so use the OS default
43+
return bytes.Equal(defaultLineBreak, crlf)
44+
}
45+
}
46+
47+
// splitSingleFile split a multidocument using all possible document delimiters
48+
func splitSingleFile(data []byte) [][]byte {
49+
parts := [][]byte{data}
50+
for _, delimiter := range delimiters {
51+
var intermediate [][]byte
52+
for _, part := range parts {
53+
intermediate = append(intermediate, bytes.Split(part, delimiter)...)
54+
}
55+
parts = intermediate
56+
}
57+
return parts
58+
}
59+
1660
// LoadFromSingleFile loads a docker app from a single-file format (as a reader)
1761
func LoadFromSingleFile(path string, r io.Reader, ops ...func(*types.App) error) (*types.App, error) {
1862
data, err := ioutil.ReadAll(r)
1963
if err != nil {
2064
return nil, errors.Wrap(err, "error reading single-file")
2165
}
22-
parts := strings.Split(string(data), types.SingleFileSeparator)
66+
67+
parts := splitSingleFile(data)
2368
if len(parts) != 3 {
2469
return nil, errors.Errorf("malformed single-file application: expected 3 documents, got %d", len(parts))
2570
}
26-
// 0. is metadata
27-
metadata := strings.NewReader(parts[0])
28-
// 1. is compose
29-
compose := strings.NewReader(parts[1])
30-
// 2. is parameters
31-
parameters := strings.NewReader(parts[2])
71+
72+
var (
73+
metadata io.Reader
74+
compose io.Reader
75+
params io.Reader
76+
)
77+
for i := 0; i < 3; i++ {
78+
parsed, err := loader.ParseYAML(parts[i])
79+
if err != nil {
80+
return nil, err
81+
}
82+
if err := specification.Validate(parsed, internal.MetadataVersion); metadata == nil && err == nil {
83+
metadata = bytes.NewBuffer(parts[i])
84+
} else if err2 := schema.Validate(parsed, schema.Version(parsed)); compose == nil && err2 == nil {
85+
compose = bytes.NewBuffer(parts[i])
86+
} else if params == nil {
87+
params = bytes.NewBuffer(parts[i])
88+
} else {
89+
return nil, errors.New("malformed single-file application")
90+
}
91+
}
92+
3293
appOps := append([]func(*types.App) error{
3394
types.WithComposes(compose),
34-
types.WithParameters(parameters),
95+
types.WithParameters(params),
3596
types.Metadata(metadata),
97+
types.WithCRLF(useCRLF(data)),
3698
}, ops...)
3799
return types.NewApp(path, appOps...)
38100
}

loader/loader_test.go

Lines changed: 47 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -18,26 +18,48 @@ import (
1818

1919
const (
2020
metadata = `name: my-app
21-
version: 1.0.0`
22-
yaml = `version: "3.1"
23-
21+
version: 1.0.0
22+
`
23+
compose = `version: "3.1"
2424
services:
2525
web:
26-
image: nginx`
27-
parameters = `foo: bar`
26+
image: nginx
27+
`
28+
params = `foo: bar
29+
`
2830
)
2931

3032
func TestLoadFromSingleFile(t *testing.T) {
31-
singlefile := fmt.Sprintf(`%s
32-
---
33-
%s
34-
---
35-
%s`, metadata, yaml, parameters)
36-
app, err := LoadFromSingleFile("my-app", strings.NewReader(singlefile))
37-
assert.NilError(t, err)
38-
assert.Assert(t, app != nil)
39-
assert.Assert(t, is.Equal(app.Path, "my-app"))
40-
assertAppContent(t, app)
33+
testCases := []struct {
34+
name string
35+
file string
36+
}{
37+
{
38+
name: "line-feed",
39+
file: fmt.Sprintf("%s\n---\n%s\n---\n%s", metadata, compose, params),
40+
},
41+
{
42+
name: "carriage-return-line-feed",
43+
file: fmt.Sprintf("%s\r\n---\r\n%s\r\n---\r\n%s", metadata, compose, params),
44+
},
45+
{
46+
name: "mixed-carriage-return-line-feed",
47+
file: fmt.Sprintf("%s\r\n---\r\n%s\r\n---\n%s", metadata, compose, params),
48+
},
49+
{
50+
name: "unordered-documents",
51+
file: fmt.Sprintf("%s\n---\n%s\n---\n%s", params, metadata, compose),
52+
},
53+
}
54+
for _, test := range testCases {
55+
t.Run(test.name, func(t *testing.T) {
56+
app, err := LoadFromSingleFile("my-app", strings.NewReader(test.file))
57+
assert.NilError(t, err)
58+
assert.Assert(t, app != nil)
59+
assert.Assert(t, is.Equal(app.Path, "my-app"))
60+
assertAppContent(t, app)
61+
})
62+
}
4163
}
4264

4365
func TestLoadFromSingleFileInvalidReader(t *testing.T) {
@@ -46,17 +68,17 @@ func TestLoadFromSingleFileInvalidReader(t *testing.T) {
4668
}
4769

4870
func TestLoadFromSingleFileMalformed(t *testing.T) {
49-
_, err := LoadFromSingleFile("my-app", strings.NewReader(`foo
71+
_, err := LoadFromSingleFile("my-app", strings.NewReader(`foo: foo
5072
---
51-
bar`))
73+
bar: bar`))
5274
assert.ErrorContains(t, err, "malformed single-file application")
5375
}
5476

5577
func TestLoadFromDirectory(t *testing.T) {
5678
dir := fs.NewDir(t, "my-app",
5779
fs.WithFile(internal.MetadataFileName, metadata),
58-
fs.WithFile(internal.ParametersFileName, parameters),
59-
fs.WithFile(internal.ComposeFileName, yaml),
80+
fs.WithFile(internal.ParametersFileName, params),
81+
fs.WithFile(internal.ComposeFileName, compose),
6082
)
6183
defer dir.Remove()
6284
app, err := LoadFromDirectory(dir.Path())
@@ -69,8 +91,8 @@ func TestLoadFromDirectory(t *testing.T) {
6991
func TestLoadFromDirectoryDeprecatedSettings(t *testing.T) {
7092
dir := fs.NewDir(t, "my-app",
7193
fs.WithFile(internal.MetadataFileName, metadata),
72-
fs.WithFile(internal.DeprecatedSettingsFileName, parameters),
73-
fs.WithFile(internal.ComposeFileName, yaml),
94+
fs.WithFile(internal.DeprecatedSettingsFileName, params),
95+
fs.WithFile(internal.ComposeFileName, compose),
7496
)
7597
defer dir.Remove()
7698
app, err := LoadFromDirectory(dir.Path())
@@ -97,8 +119,8 @@ func createAppTar(t *testing.T) *fs.File {
97119
t.Helper()
98120
dir := fs.NewDir(t, "my-app",
99121
fs.WithFile(internal.MetadataFileName, metadata),
100-
fs.WithFile(internal.ParametersFileName, parameters),
101-
fs.WithFile(internal.ComposeFileName, yaml),
122+
fs.WithFile(internal.ParametersFileName, params),
123+
fs.WithFile(internal.ComposeFileName, compose),
102124
)
103125
defer dir.Remove()
104126
r, err := archive.TarWithOptions(dir.Path(), &archive.TarOptions{
@@ -117,9 +139,9 @@ func assertContentIs(t *testing.T, actual []byte, expected string) {
117139

118140
func assertAppContent(t *testing.T, app *types.App) {
119141
assert.Assert(t, is.Len(app.ParametersRaw(), 1))
120-
assertContentIs(t, app.ParametersRaw()[0], parameters)
142+
assertContentIs(t, app.ParametersRaw()[0], params)
121143
assert.Assert(t, is.Len(app.Composes(), 1))
122-
assertContentIs(t, app.Composes()[0], yaml)
144+
assertContentIs(t, app.Composes()[0], compose)
123145
assertContentIs(t, app.MetadataRaw(), metadata)
124146
}
125147

loader/loader_unix.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// +build !windows
2+
3+
package loader
4+
5+
var (
6+
defaultLineBreak = []byte{'\n'}
7+
)

0 commit comments

Comments
 (0)