1
0

export.go 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. // Package export writes a project to a folder or a zip file: the .xpj and
  2. // its "<Name>_[ProjectData]" folder holding every pool file (build plan 4.4).
  3. package export
  4. import (
  5. "archive/zip"
  6. "bytes"
  7. "errors"
  8. "fmt"
  9. "io"
  10. "os"
  11. "path/filepath"
  12. "strings"
  13. "time"
  14. "git.monoexp.com/bajdev/mpckit/internal/project"
  15. )
  16. // ExistsError is returned when the destination already holds the project
  17. // and overwriting was not allowed. The caller asks the user, then retries.
  18. type ExistsError struct {
  19. Paths []string
  20. }
  21. func (e *ExistsError) Error() string {
  22. return "already exists: " + strings.Join(e.Paths, ", ")
  23. }
  24. // MissingError lists pool files whose source can no longer be found.
  25. type MissingError struct {
  26. Files []string // pool file names
  27. Paths []string // where each was expected
  28. }
  29. func (e *MissingError) Error() string {
  30. var b strings.Builder
  31. fmt.Fprintf(&b, "%d sample file(s) are missing:", len(e.Files))
  32. for i := range e.Files {
  33. fmt.Fprintf(&b, "\n %s (%s)", e.Files[i], e.Paths[i])
  34. }
  35. return b.String()
  36. }
  37. type item struct {
  38. name string // file name inside the data folder
  39. src string
  40. }
  41. // Usable reports whether src can be exported: a regular file, not a link,
  42. // in a folder that is not a link either. A link in a shared kit, or a data
  43. // folder that is itself a link, could otherwise export any file it points
  44. // to. Links the user picks are resolved when the sample is assigned.
  45. func Usable(src string) bool {
  46. st, err := os.Lstat(src)
  47. if err != nil || !st.Mode().IsRegular() {
  48. return false
  49. }
  50. dir, err := os.Lstat(filepath.Dir(src))
  51. return err == nil && dir.IsDir()
  52. }
  53. // collect checks that every pool file has a usable source. A source that
  54. // is a link or not a regular file is reported as missing.
  55. func collect(p *project.Project) ([]item, error) {
  56. var items []item
  57. missing := &MissingError{}
  58. for _, f := range p.PoolFiles() {
  59. src, ok := p.Source(f)
  60. if !ok || src == "" {
  61. missing.Files = append(missing.Files, f)
  62. missing.Paths = append(missing.Paths, "no source recorded")
  63. continue
  64. }
  65. if !Usable(src) {
  66. missing.Files = append(missing.Files, f)
  67. missing.Paths = append(missing.Paths, src)
  68. continue
  69. }
  70. items = append(items, item{name: f, src: src})
  71. }
  72. if len(missing.Files) > 0 {
  73. return nil, missing
  74. }
  75. return items, nil
  76. }
  77. func encode(p *project.Project) ([]byte, error) {
  78. var b bytes.Buffer
  79. if err := p.File().Write(&b); err != nil {
  80. return nil, err
  81. }
  82. return b.Bytes(), nil
  83. }
  84. func dataDir(p *project.Project) string { return p.Name + project.DataSuffix }
  85. // checkNames refuses pool file names that would reach outside the data
  86. // folder. Load and AssignSample already ensure this; it is checked again
  87. // where files are written.
  88. func checkNames(items []item) error {
  89. for _, it := range items {
  90. if err := project.PlainFileName(it.name); err != nil {
  91. return fmt.Errorf("sample %q: %w", it.name, err)
  92. }
  93. }
  94. return nil
  95. }
  96. // rename is os.Rename; tests replace it to make a rename fail.
  97. var rename = os.Rename
  98. // Folder writes <dest>/<Name>.xpj and <dest>/<Name>_[ProjectData]/ and
  99. // returns the path of the .xpj. If either target exists and overwrite is
  100. // false, nothing is written and an *ExistsError is returned.
  101. //
  102. // The new pair is written in full to a temporary folder in dest first. To
  103. // replace an existing pair, Folder moves it into that temporary folder,
  104. // moves the new pair into place, and only then deletes the old pair. If
  105. // any step fails, the old pair is moved back, so an existing export is
  106. // either replaced completely or left as it was. If even moving it back
  107. // fails, the error says where the old pair was kept.
  108. func Folder(p *project.Project, dest string, overwrite bool) (string, error) {
  109. if err := project.ValidName(p.Name); err != nil {
  110. return "", fmt.Errorf("project name: %w", err)
  111. }
  112. items, err := collect(p)
  113. if err != nil {
  114. return "", err
  115. }
  116. if err := checkNames(items); err != nil {
  117. return "", err
  118. }
  119. if _, err := os.Stat(dest); errors.Is(err, os.ErrNotExist) {
  120. return "", fmt.Errorf("export folder does not exist: %s", dest)
  121. }
  122. xpjPath := filepath.Join(dest, p.Name+".xpj")
  123. dirPath := filepath.Join(dest, dataDir(p))
  124. if !overwrite {
  125. if exists := existing(xpjPath, dirPath); len(exists) > 0 {
  126. return "", &ExistsError{Paths: exists}
  127. }
  128. }
  129. body, err := encode(p)
  130. if err != nil {
  131. return "", err
  132. }
  133. tmp, err := os.MkdirTemp(dest, ".mpckit-export-")
  134. if err != nil {
  135. return "", err
  136. }
  137. keepTmp := false
  138. defer func() {
  139. if !keepTmp {
  140. os.RemoveAll(tmp)
  141. }
  142. }()
  143. tmpData := filepath.Join(tmp, dataDir(p))
  144. if err := os.Mkdir(tmpData, 0o755); err != nil {
  145. return "", err
  146. }
  147. for _, it := range items {
  148. if err := copyFile(it.src, filepath.Join(tmpData, it.name)); err != nil {
  149. return "", err
  150. }
  151. }
  152. tmpXPJ := filepath.Join(tmp, p.Name+".xpj")
  153. if err := os.WriteFile(tmpXPJ, body, 0o644); err != nil {
  154. return "", err
  155. }
  156. // Move each existing target aside, then each new one into place. A
  157. // failure undoes the moves already made, newest first.
  158. old := filepath.Join(tmp, "old")
  159. if err := os.Mkdir(old, 0o755); err != nil {
  160. return "", err
  161. }
  162. type move struct{ from, to string }
  163. var moves []move
  164. var moveErr error
  165. if overwrite {
  166. for _, target := range existing(dirPath, xpjPath) {
  167. m := move{target, filepath.Join(old, filepath.Base(target))}
  168. if moveErr = rename(m.from, m.to); moveErr != nil {
  169. break
  170. }
  171. moves = append(moves, m)
  172. }
  173. }
  174. if moveErr == nil {
  175. for _, m := range []move{{tmpData, dirPath}, {tmpXPJ, xpjPath}} {
  176. if moveErr = rename(m.from, m.to); moveErr != nil {
  177. break
  178. }
  179. moves = append(moves, m)
  180. }
  181. }
  182. if moveErr == nil {
  183. return xpjPath, nil // the deferred RemoveAll deletes the old pair
  184. }
  185. for i := len(moves) - 1; i >= 0; i-- {
  186. if err := rename(moves[i].to, moves[i].from); err != nil {
  187. keepTmp = true
  188. return "", fmt.Errorf("%w; the previous export could not be put back and is kept in %s", moveErr, old)
  189. }
  190. }
  191. return "", moveErr
  192. }
  193. func existing(paths ...string) []string {
  194. var out []string
  195. for _, p := range paths {
  196. if _, err := os.Lstat(p); err == nil {
  197. out = append(out, p)
  198. }
  199. }
  200. return out
  201. }
  202. func copyFile(src, dst string) error {
  203. in, err := os.Open(src)
  204. if err != nil {
  205. return err
  206. }
  207. defer in.Close()
  208. out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
  209. if err != nil {
  210. return err
  211. }
  212. if _, err := io.Copy(out, in); err != nil {
  213. out.Close()
  214. return err
  215. }
  216. return out.Close()
  217. }
  218. // Zip writes a zip file at path holding <Name>.xpj and
  219. // <Name>_[ProjectData]/ at its root, ready to extract onto the device's
  220. // card. It writes to a temporary file and renames it at the end. If path
  221. // exists and overwrite is false, an *ExistsError is returned.
  222. func Zip(p *project.Project, path string, overwrite bool) error {
  223. if err := project.ValidName(p.Name); err != nil {
  224. return fmt.Errorf("project name: %w", err)
  225. }
  226. items, err := collect(p)
  227. if err != nil {
  228. return err
  229. }
  230. if !overwrite {
  231. if exists := existing(path); len(exists) > 0 {
  232. return &ExistsError{Paths: exists}
  233. }
  234. }
  235. body, err := encode(p)
  236. if err != nil {
  237. return err
  238. }
  239. tmp, err := os.CreateTemp(filepath.Dir(path), ".mpckit-export-*.zip")
  240. if err != nil {
  241. return err
  242. }
  243. defer os.Remove(tmp.Name())
  244. if err := writeZip(tmp, p, body, items); err != nil {
  245. tmp.Close()
  246. return err
  247. }
  248. if err := tmp.Close(); err != nil {
  249. return err
  250. }
  251. if err := os.Chmod(tmp.Name(), 0o644); err != nil {
  252. return err
  253. }
  254. return os.Rename(tmp.Name(), path)
  255. }
  256. func writeZip(w io.Writer, p *project.Project, body []byte, items []item) error {
  257. if err := checkNames(items); err != nil {
  258. return err
  259. }
  260. now := time.Now()
  261. zw := zip.NewWriter(w)
  262. // The .xpj is already gzip-compressed: store it.
  263. fw, err := zw.CreateHeader(&zip.FileHeader{Name: p.Name + ".xpj", Method: zip.Store, Modified: now})
  264. if err != nil {
  265. return err
  266. }
  267. if _, err := fw.Write(body); err != nil {
  268. return err
  269. }
  270. dir := dataDir(p) + "/"
  271. if _, err := zw.CreateHeader(&zip.FileHeader{Name: dir, Modified: now}); err != nil {
  272. return err
  273. }
  274. for _, it := range items {
  275. fw, err := zw.CreateHeader(&zip.FileHeader{Name: dir + it.name, Method: zip.Deflate, Modified: now})
  276. if err != nil {
  277. return err
  278. }
  279. in, err := os.Open(it.src)
  280. if err != nil {
  281. return err
  282. }
  283. _, err = io.Copy(fw, in)
  284. in.Close()
  285. if err != nil {
  286. return err
  287. }
  288. }
  289. return zw.Close()
  290. }