Merge lp:~axwalk/juju-core/sshstorage-stream-input into lp:~go-bot/juju-core/trunk
- sshstorage-stream-input
- Merge into trunk
Status: | Merged |
---|---|
Approved by: | Andrew Wilkins |
Approved revision: | no longer in the source branch. |
Merged at revision: | 1922 |
Proposed branch: | lp:~axwalk/juju-core/sshstorage-stream-input |
Merge into: | lp:~go-bot/juju-core/trunk |
Diff against target: |
366 lines (+256/-22) 6 files modified
environs/sshstorage/export_test.go (+8/-0) environs/sshstorage/linewrapwriter.go (+57/-0) environs/sshstorage/linewrapwriter_test.go (+151/-0) environs/sshstorage/storage.go (+26/-17) environs/sshstorage/storage_test.go (+0/-5) environs/sshstorage/suite_test.go (+14/-0) |
To merge this branch: | bzr merge lp:~axwalk/juju-core/sshstorage-stream-input |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Juju Engineering | Pending | ||
Review via email: mp+188508@code.launchpad.net |
Commit message
environs/
Change sshstorage's Put to stream to the
ssh command's stdin, rather than reading
into memory and then writing.
Also, drive-by: use `mktemp --tmpdir` rather
than exporting $TMPDIR.
Description of the change
environs/
Change sshstorage's Put to stream to the
ssh command's stdin, rather than reading
into memory and then writing.
Also, drive-by: use `mktemp --tmpdir` rather
than exporting $TMPDIR.
Andrew Wilkins (axwalk) wrote : | # |
Andrew Wilkins (axwalk) wrote : | # |
Please take a look.
Andrew Wilkins (axwalk) wrote : | # |
Please take a look.
Roger Peppe (rogpeppe) wrote : | # |
Thanks for doing this. Looks good, with a few suggestions below.
https:/
File environs/
https:/
environs/
command)
s/EOF/'@EOF'/
using quotes means that the shell doesn't scan through
for potential variable expansion.
The @ character is just so it can't appear in a normal
base64 output.
https:/
environs/
utils.NewWrapWr
You should consider wrapping s.stdin in a bufio.Writer here.
(perhaps even wrap it inside the SSHStorage and flushing
when appropriate)
https:/
File utils/wrapwriter.go (right):
https:/
utils/wrapwrite
s/n/total/
https:/
utils/wrapwrite
s/n/total/
https:/
File utils/wrapwrite
https:/
utils/wrapwrite
I'd like to see better tests here:
- what happens when writes return errors or part
- does it cope ok with several writes in a row?
For the latter, perhaps take a piece of input, split it up
many different ways (use some simple algorithm or
table to do that), and check that the output is
always the same.
John A Meinel (jameinel) wrote : | # |
https:/
File environs/
https:/
environs/
command)
On 2013/10/01 10:27:39, rog wrote:
> s/EOF/'@EOF'/
> using quotes means that the shell doesn't scan through
> for potential variable expansion.
> The @ character is just so it can't appear in a normal
> base64 output.
Sounds interesting, though we should test that it works properly with
/bin/dash.
Roger Peppe (rogpeppe) wrote : | # |
On 1 October 2013 12:09, <email address hidden> wrote:
>
> https:/
> File environs/
>
> https:/
> environs/
> command)
> On 2013/10/01 10:27:39, rog wrote:
>>
>> s/EOF/'@EOF'/
>
>
>> using quotes means that the shell doesn't scan through
>> for potential variable expansion.
>
>
>> The @ character is just so it can't appear in a normal
>> base64 output.
>
>
> Sounds interesting, though we should test that it works properly with
> /bin/dash.
It does (it's original Bourne shell behaviour).
Andrew Wilkins (axwalk) wrote : | # |
Please take a look.
Roger Peppe (rogpeppe) wrote : | # |
LGTM modulo the below suggestions. Thanks!
https:/
File environs/
https:/
environs/
bytes.
// Moreover it gives no consideration to multibyte
// utf-8 characters, which it can split arbitrarily.
https:/
File environs/
https:/
environs/
I'm not sure this half-way house is great.
I'd either make s.stdin a bufio.Writer, or
call NewWriter inside the later "if input != nil" block,
avoiding the ugly dynamic type conversion either way,
and allowing the use of the more natural WriteString
methods.
https:/
environs/
outer-most command.
That's not actually the case, I think. Did you try it?
Andrew Wilkins (axwalk) wrote : | # |
Please take a look.
Andrew Wilkins (axwalk) wrote : | # |
https:/
File environs/
https:/
environs/
bytes.
On 2013/10/01 15:28:47, rog wrote:
> // Moreover it gives no consideration to multibyte
> // utf-8 characters, which it can split arbitrarily.
Done.
https:/
File environs/
https:/
environs/
On 2013/10/01 15:28:47, rog wrote:
> I'm not sure this half-way house is great.
> I'd either make s.stdin a bufio.Writer, or
> call NewWriter inside the later "if input != nil" block,
> avoiding the ugly dynamic type conversion either way,
> and allowing the use of the more natural WriteString
> methods.
It's not in the later block because it's used before then. If it were in
there, then we'd have two writes to s.stdin.
I agree about the ugliness, though. I'll wrap s.stdin, but only in this
function so as not to lose the WriteCloser interface.
https:/
environs/
outer-most command.
On 2013/10/01 15:28:47, rog wrote:
> That's not actually the case, I think. Did you try it?
I did, but I'm not sure what I was doing before to cause it to fail. I
*think* I may have had a bug where something was being wrapped twice,
and ended up as ((command << EOF)).
Anyway, I've put it after 'base64 -d' and it does indeed work fine.
Thanks.
Go Bot (go-bot) wrote : | # |
There are additional revisions which have not been approved in review. Please seek review and approval of these new revisions.
Preview Diff
1 | === added file 'environs/sshstorage/export_test.go' |
2 | --- environs/sshstorage/export_test.go 1970-01-01 00:00:00 +0000 |
3 | +++ environs/sshstorage/export_test.go 2013-10-02 05:50:56 +0000 |
4 | @@ -0,0 +1,8 @@ |
5 | +// Copyright 2012, 2013 Canonical Ltd. |
6 | +// Licensed under the AGPLv3, see LICENCE file for details. |
7 | + |
8 | +package sshstorage |
9 | + |
10 | +var ( |
11 | + NewLineWrapWriter = newLineWrapWriter |
12 | +) |
13 | |
14 | === added file 'environs/sshstorage/linewrapwriter.go' |
15 | --- environs/sshstorage/linewrapwriter.go 1970-01-01 00:00:00 +0000 |
16 | +++ environs/sshstorage/linewrapwriter.go 2013-10-02 05:50:56 +0000 |
17 | @@ -0,0 +1,57 @@ |
18 | +// Copyright 2013 Canonical Ltd. |
19 | +// Licensed under the AGPLv3, see LICENCE file for details. |
20 | + |
21 | +package sshstorage |
22 | + |
23 | +import ( |
24 | + "fmt" |
25 | + "io" |
26 | +) |
27 | + |
28 | +type lineWrapWriter struct { |
29 | + out io.Writer |
30 | + remain int |
31 | + max int |
32 | +} |
33 | + |
34 | +// NewLineWrapWriter returns an io.Writer that encloses the given |
35 | +// io.Writer, wrapping lines at the the specified line length. |
36 | +// |
37 | +// Note: there is no special consideration for input that |
38 | +// already contains newlines; this will simply add newlines |
39 | +// after every "lineLength" bytes. Moreover it gives no |
40 | +// consideration to multibyte utf-8 characters, which it can split |
41 | +// arbitrarily. |
42 | +// |
43 | +// This is currently only appropriate for wrapping base64-encoded |
44 | +// data, which is why it lives here. |
45 | +func newLineWrapWriter(out io.Writer, lineLength int) (io.Writer, error) { |
46 | + if lineLength <= 0 { |
47 | + return nil, fmt.Errorf("line length %d <= 0", lineLength) |
48 | + } |
49 | + return &lineWrapWriter{ |
50 | + out: out, |
51 | + remain: lineLength, |
52 | + max: lineLength, |
53 | + }, nil |
54 | +} |
55 | + |
56 | +func (w *lineWrapWriter) Write(buf []byte) (int, error) { |
57 | + total := 0 |
58 | + for len(buf) >= w.remain { |
59 | + n, err := w.out.Write(buf[0:w.remain]) |
60 | + w.remain -= n |
61 | + total += n |
62 | + if err != nil { |
63 | + return total, err |
64 | + } |
65 | + if _, err := w.out.Write([]byte("\n")); err != nil { |
66 | + return total, err |
67 | + } |
68 | + w.remain = w.max |
69 | + buf = buf[n:] |
70 | + } |
71 | + n, err := w.out.Write(buf) |
72 | + w.remain -= n |
73 | + return total + n, err |
74 | +} |
75 | |
76 | === added file 'environs/sshstorage/linewrapwriter_test.go' |
77 | --- environs/sshstorage/linewrapwriter_test.go 1970-01-01 00:00:00 +0000 |
78 | +++ environs/sshstorage/linewrapwriter_test.go 2013-10-02 05:50:56 +0000 |
79 | @@ -0,0 +1,151 @@ |
80 | +// Copyright 2013 Canonical Ltd. |
81 | +// Licensed under the AGPLv3, see LICENCE file for details. |
82 | + |
83 | +package sshstorage_test |
84 | + |
85 | +import ( |
86 | + "bytes" |
87 | + "errors" |
88 | + "io" |
89 | + |
90 | + gc "launchpad.net/gocheck" |
91 | + |
92 | + "launchpad.net/juju-core/environs/sshstorage" |
93 | +) |
94 | + |
95 | +type wrapWriterSuite struct{} |
96 | + |
97 | +var _ = gc.Suite(&wrapWriterSuite{}) |
98 | + |
99 | +func (*wrapWriterSuite) TestLineWrapWriterBadLength(c *gc.C) { |
100 | + var buf bytes.Buffer |
101 | + w, err := sshstorage.NewLineWrapWriter(&buf, 0) |
102 | + c.Assert(err, gc.ErrorMatches, "line length 0 <= 0") |
103 | + c.Assert(w, gc.IsNil) |
104 | + w, err = sshstorage.NewLineWrapWriter(&buf, -1) |
105 | + c.Assert(err, gc.ErrorMatches, "line length -1 <= 0") |
106 | +} |
107 | + |
108 | +func (*wrapWriterSuite) TestLineWrapWriter(c *gc.C) { |
109 | + type test struct { |
110 | + input []string |
111 | + lineLength int |
112 | + expected string |
113 | + } |
114 | + tests := []test{{ |
115 | + input: []string{""}, |
116 | + lineLength: 1, |
117 | + expected: "", |
118 | + }, { |
119 | + input: []string{"hi!"}, |
120 | + lineLength: 1, |
121 | + expected: "h\ni\n!\n", |
122 | + }, { |
123 | + input: []string{"hi!"}, |
124 | + lineLength: 2, |
125 | + // Note: no trailing newline. |
126 | + expected: "hi\n!", |
127 | + }, { |
128 | + input: []string{"", "h", "i!"}, |
129 | + lineLength: 2, |
130 | + expected: "hi\n!", |
131 | + }, { |
132 | + input: []string{"", "h", "i!"}, |
133 | + lineLength: 2, |
134 | + expected: "hi\n!", |
135 | + }, { |
136 | + input: []string{"hi", "!!"}, |
137 | + lineLength: 2, |
138 | + expected: "hi\n!!\n", |
139 | + }, { |
140 | + input: []string{"hi", "!", "!"}, |
141 | + lineLength: 2, |
142 | + expected: "hi\n!!\n", |
143 | + }, { |
144 | + input: []string{"h", "i", "!!"}, |
145 | + lineLength: 2, |
146 | + expected: "hi\n!!\n", |
147 | + }} |
148 | + for i, t := range tests { |
149 | + c.Logf("test %d: %q, line length %d", i, t.input, t.lineLength) |
150 | + var buf bytes.Buffer |
151 | + w, err := sshstorage.NewLineWrapWriter(&buf, t.lineLength) |
152 | + c.Assert(err, gc.IsNil) |
153 | + c.Assert(w, gc.NotNil) |
154 | + for _, input := range t.input { |
155 | + n, err := w.Write([]byte(input)) |
156 | + c.Assert(err, gc.IsNil) |
157 | + c.Assert(n, gc.Equals, len(input)) |
158 | + } |
159 | + c.Assert(buf.String(), gc.Equals, t.expected) |
160 | + } |
161 | +} |
162 | + |
163 | +type limitedWriter struct { |
164 | + io.Writer |
165 | + remaining int |
166 | +} |
167 | + |
168 | +var writeLimited = errors.New("write limited") |
169 | + |
170 | +func (w *limitedWriter) Write(buf []byte) (int, error) { |
171 | + inputlen := len(buf) |
172 | + if len(buf) > w.remaining { |
173 | + buf = buf[:w.remaining] |
174 | + } |
175 | + n, err := w.Writer.Write(buf) |
176 | + w.remaining -= n |
177 | + if n < inputlen && err == nil { |
178 | + err = writeLimited |
179 | + } |
180 | + return n, err |
181 | +} |
182 | + |
183 | +func (*wrapWriterSuite) TestLineWrapWriterErrors(c *gc.C) { |
184 | + // Note: after an error is returned, all bets are off. |
185 | + // In the only place we use this code, we bail out immediately. |
186 | + const lineLength = 3 |
187 | + type test struct { |
188 | + input string |
189 | + output string |
190 | + limit int |
191 | + written int |
192 | + err error |
193 | + } |
194 | + tests := []test{{ |
195 | + input: "abc", |
196 | + output: "abc", |
197 | + limit: 3, // "\n" will be limited |
198 | + written: 3, |
199 | + err: writeLimited, |
200 | + }, { |
201 | + input: "abc", |
202 | + output: "abc\n", |
203 | + limit: 4, |
204 | + written: 3, // 3/3 bytes of input |
205 | + }, { |
206 | + input: "abc", |
207 | + output: "ab", |
208 | + limit: 2, |
209 | + written: 2, // 2/3 bytes of input |
210 | + err: writeLimited, |
211 | + }, { |
212 | + input: "abc!", |
213 | + output: "abc\n", |
214 | + limit: 4, |
215 | + written: 3, // 3/4 bytes of input |
216 | + err: writeLimited, |
217 | + }} |
218 | + for i, t := range tests { |
219 | + c.Logf("test %d: %q, limit %d", i, t.input, t.limit) |
220 | + var buf bytes.Buffer |
221 | + wrapWriter := &limitedWriter{&buf, t.limit} |
222 | + w, err := sshstorage.NewLineWrapWriter(wrapWriter, lineLength) |
223 | + c.Assert(err, gc.IsNil) |
224 | + c.Assert(w, gc.NotNil) |
225 | + n, err := w.Write([]byte(t.input)) |
226 | + c.Assert(n, gc.Equals, t.written) |
227 | + c.Assert(buf.String(), gc.Equals, t.output) |
228 | + c.Assert(err, gc.Equals, t.err) |
229 | + } |
230 | +} |
231 | |
232 | === modified file 'environs/sshstorage/storage.go' |
233 | --- environs/sshstorage/storage.go 2013-09-27 05:19:30 +0000 |
234 | +++ environs/sshstorage/storage.go 2013-10-02 05:50:56 +0000 |
235 | @@ -22,6 +22,10 @@ |
236 | "launchpad.net/juju-core/utils" |
237 | ) |
238 | |
239 | +// base64LineLength is the default line length for wrapping |
240 | +// output generated by the base64 command line utility. |
241 | +const base64LineLength = 76 |
242 | + |
243 | // SSHStorage implements storage.Storage. |
244 | // |
245 | // The storage is created under sudo, and ownership given over to the |
246 | @@ -129,10 +133,10 @@ |
247 | |
248 | func (s *SSHStorage) runf(flockmode flockmode, command string, args ...interface{}) (string, error) { |
249 | command = fmt.Sprintf(command, args...) |
250 | - return s.run(flockmode, command, nil) |
251 | + return s.run(flockmode, command, nil, 0) |
252 | } |
253 | |
254 | -func (s *SSHStorage) run(flockmode flockmode, command string, input []byte) (string, error) { |
255 | +func (s *SSHStorage) run(flockmode flockmode, command string, input io.Reader, inputlen int64) (string, error) { |
256 | const rcPrefix = "JUJU-RC: " |
257 | command = fmt.Sprintf( |
258 | "SHELL=/bin/bash flock %s %s -c %s", |
259 | @@ -140,23 +144,32 @@ |
260 | s.remotepath, |
261 | utils.ShQuote(command), |
262 | ) |
263 | - var encoded string |
264 | + stdin := bufio.NewWriter(s.stdin) |
265 | if input != nil { |
266 | - encoded = base64.StdEncoding.EncodeToString(input) |
267 | - command = fmt.Sprintf( |
268 | - "head -q -c %d | base64 -d | (%s)", |
269 | - len(encoded), |
270 | - command, |
271 | - ) |
272 | + command = fmt.Sprintf("base64 -d << '@EOF' | (%s)", command) |
273 | } |
274 | command = fmt.Sprintf("(%s) 2>&1; echo %s$?", command, rcPrefix) |
275 | - if _, err := s.stdin.Write([]byte(command + "\n")); err != nil { |
276 | + if _, err := stdin.WriteString(command + "\n"); err != nil { |
277 | return "", fmt.Errorf("failed to write command: %v", err) |
278 | } |
279 | if input != nil { |
280 | - if _, err := s.stdin.Write([]byte(encoded)); err != nil { |
281 | + wrapper, err := newLineWrapWriter(stdin, base64LineLength) |
282 | + if err != nil { |
283 | + return "", fmt.Errorf("failed to create split writer: %v", err) |
284 | + } |
285 | + encoder := base64.NewEncoder(base64.StdEncoding, wrapper) |
286 | + if _, err := io.CopyN(encoder, input, inputlen); err != nil { |
287 | return "", fmt.Errorf("failed to write input: %v", err) |
288 | } |
289 | + if err := encoder.Close(); err != nil { |
290 | + return "", fmt.Errorf("failed to flush encoder: %v", err) |
291 | + } |
292 | + if _, err := stdin.WriteString("\n@EOF\n"); err != nil { |
293 | + return "", fmt.Errorf("failed to terminate input: %v", err) |
294 | + } |
295 | + } |
296 | + if err := stdin.Flush(); err != nil { |
297 | + return "", fmt.Errorf("failed to flush input: %v", err) |
298 | } |
299 | var output []string |
300 | for s.scanner.Scan() { |
301 | @@ -259,21 +272,17 @@ |
302 | if err != nil { |
303 | return err |
304 | } |
305 | - buf := make([]byte, length) |
306 | - if _, err := io.ReadFull(r, buf); err != nil { |
307 | - return err |
308 | - } |
309 | path = utils.ShQuote(path) |
310 | tmpdir := utils.ShQuote(s.tmpdir) |
311 | |
312 | // Write to a temporary file ($TMPFILE), then mv atomically. |
313 | command := fmt.Sprintf("mkdir -p `dirname %s` && cat > $TMPFILE", path) |
314 | command = fmt.Sprintf( |
315 | - "export TMPDIR=%s && TMPFILE=`mktemp` && ((%s && mv $TMPFILE %s) || rm -f $TMPFILE)", |
316 | + "TMPFILE=`mktemp --tmpdir=%s` && ((%s && mv $TMPFILE %s) || rm -f $TMPFILE)", |
317 | tmpdir, command, path, |
318 | ) |
319 | |
320 | - _, err = s.run(flockExclusive, command+"\n", buf) |
321 | + _, err = s.run(flockExclusive, command+"\n", r, length) |
322 | return err |
323 | } |
324 | |
325 | |
326 | === modified file 'environs/sshstorage/storage_test.go' |
327 | --- environs/sshstorage/storage_test.go 2013-09-23 03:07:31 +0000 |
328 | +++ environs/sshstorage/storage_test.go 2013-10-02 05:50:56 +0000 |
329 | @@ -13,7 +13,6 @@ |
330 | "path" |
331 | "path/filepath" |
332 | "regexp" |
333 | - stdtesting "testing" |
334 | "time" |
335 | |
336 | gc "launchpad.net/gocheck" |
337 | @@ -31,10 +30,6 @@ |
338 | |
339 | var _ = gc.Suite(&storageSuite{}) |
340 | |
341 | -func Test(t *stdtesting.T) { |
342 | - gc.TestingT(t) |
343 | -} |
344 | - |
345 | func sshCommandTesting(host string, tty bool, command string) *exec.Cmd { |
346 | cmd := exec.Command("bash", "-c", command) |
347 | uid := fmt.Sprint(os.Getuid()) |
348 | |
349 | === added file 'environs/sshstorage/suite_test.go' |
350 | --- environs/sshstorage/suite_test.go 1970-01-01 00:00:00 +0000 |
351 | +++ environs/sshstorage/suite_test.go 2013-10-02 05:50:56 +0000 |
352 | @@ -0,0 +1,14 @@ |
353 | +// Copyright 2013 Canonical Ltd. |
354 | +// Licensed under the AGPLv3, see LICENCE file for details. |
355 | + |
356 | +package sshstorage_test |
357 | + |
358 | +import ( |
359 | + "testing" |
360 | + |
361 | + gc "launchpad.net/gocheck" |
362 | +) |
363 | + |
364 | +func Test(t *testing.T) { |
365 | + gc.TestingT(t) |
366 | +} |
Reviewers: mp+188508_ code.launchpad. net,
Message:
Please take a look.
Description: sshstorage: stream stdin
environs/
Change sshstorage's Put to stream to the
ssh command's stdin, rather than reading
into memory and then writing.
Also, drive-by: use `mktemp --tmpdir` rather
than exporting $TMPDIR.
https:/ /code.launchpad .net/~axwalk/ juju-core/ sshstorage- stream- input/+ merge/188508
(do not edit description out of merge proposal)
Please review this at https:/ /codereview. appspot. com/14197043/
Affected files (+15, -16 lines): sshstorage/ storage. go
A [revision details]
M environs/
Index: [revision details] 20130930183118- qlwtuzv7j6d2iy7 3
=== added file '[revision details]'
--- [revision details] 2012-01-01 00:00:00 +0000
+++ [revision details] 2012-01-01 00:00:00 +0000
@@ -0,0 +1,2 @@
+Old revision: tarmac-
+New revision: <email address hidden>
Index: environs/ sshstorage/ storage. go sshstorage/ storage. go' sshstorage/ storage. go 2013-09-24 01:55:30 +0000 sshstorage/ storage. go 2013-10-01 04:04:41 +0000
=== modified file 'environs/
--- environs/
+++ environs/
@@ -129,10 +129,10 @@
func (s *SSHStorage) runf(flockmode flockmode, command string, command, args...)
args ...interface{}) (string, error) {
command = fmt.Sprintf(
- return s.run(flockmode, command, nil)
+ return s.run(flockmode, command, nil, 0)
}
-func (s *SSHStorage) run(flockmode flockmode, command string, input /bin/bash flock %s %s -c %s", ShQuote( command) , StdEncoding. EncodeToString( input) Write([ ]byte(command + "\n")); err != nil { Write([ ]byte(encoded) ); err != nil { NewEncoder( base64. StdEncoding, s.stdin) Write([ ]byte(" \n")); err != nil { s.tmpdir)
[]byte) (string, error) {
+func (s *SSHStorage) run(flockmode flockmode, command string, input
io.Reader, inputlen int64) (string, error) {
const rcPrefix = "JUJU-RC: "
command = fmt.Sprintf(
"SHELL=
@@ -140,23 +140,24 @@
s.remotepath,
utils.
)
- var encoded string
if input != nil {
- encoded = base64.
- command = fmt.Sprintf(
- "head -q -c %d | base64 -d | (%s)",
- len(encoded),
- command,
- )
+ command = fmt.Sprintf("head -q -n 1 | base64 -d | (%s)", command)
}
command = fmt.Sprintf("(%s) 2>&1; echo %s$?", command, rcPrefix)
if _, err := s.stdin.
return "", fmt.Errorf("failed to write command: %v", err)
}
if input != nil {
- if _, err := s.stdin.
+ encoder := base64.
+ if _, err := io.CopyN(encoder, input, inputlen); err != nil {
return "", fmt.Errorf("failed to write input: %v", err)
}
+ if err := encoder.Close(); err != nil {
+ return "", fmt.Errorf("failed to flush input: %v", err)
+ }
+ if _, err := s.stdin.
+ return "", fmt.Errorf("failed to terminate input: %v", err)
+ }
}
var output []string
for s.scanner.Scan() {
@@ -259,21 +260,17 @@
if err != nil {
return err
}
- buf := make([]byte, length)
- if _, err := r.Read(buf); err != nil {
- return err
- }
path = utils.ShQuote(path)
tmpdir := utils.ShQuote(
// Write to a temporary file ($TMPFILE), then mv atomically.
command := fmt.Sprintf("mkdir -p `dirname %s` && cat > $TMPFILE", path)
command = fmt.Sprintf(
- "export TMPDIR=%...